TypeScript 令我苦不堪言
你是否被 TypeScript 的炒作假象所欺骗?TypeScript 真比 JavaScript 好吗?我对这一点深表怀疑。而且我认为,在大多数情况下,JavaScript 可能是更好的选择。
在本文中,我们就来看一看具体的原因。
类型系统被高估
许多人对类型系统极其信赖。我也比较同意,类型系统消除了程序中的大量错误,而且也降低了重构的难度。但是,类型系统只是一个方面,还有比静态类型更重要的方面,选择编程语言时,我们不能仅凭是否有类型系统下结论。
纵观整个职业生涯,我曾使用过很多编程语言。我可以告诉你,TypeScript 类型系统非常初级,特别是与其他现代语言相比(例如 Rust、Scala、Haskell、OCaml 等)。
如果某个编程语言拥有类型系统,那么拥有类型推断也非常有用。优秀的类型系统能够推断大多数类型,而且无需明确标注函数签名。不幸的是,TypeScript 只提供的了最基本的类型推断。
下面,我们来看一看除了基本的类型系统之外,TypeScript 是否还提供了其他有价值的东西。
Null
我认为 Null 是一个十亿美元的错误。Null 引用于 1965 年问世。那时,我正在设计第一个面向对象语言的综合类型系统。我的目标是确保所有引用的使用都绝对安全,并由编译器自动执行检查。但是,我没忍住,添加了一个 null 引用,仅仅是因为它很容易实现。这导致了无数的错误、漏洞和系统崩溃,在最近四十年中造成的经济损失高达十亿美元。
—— Tony Hoare,Null 引用的发明者
为什么Null引用不好?Null 引用破坏了类型系统。当默认值为 Null 时,我们就无法再依赖编译器来检查代码的有效性。任何可为 Null 的值都形同于定时炸弹。那么,如果我们使用了某个不可能为 Null 的值,但实际真的为 Null,该怎么办?我们会得到一个运行时异常。
function capitalize(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
capitalize("john"); // -> "John"
capitalize(null); // Uncaught TypeError: Cannot read property 'charAt' of null
我们必须依靠手动运行时检查来确保所处理的值不为 null。即使是像 TypeScript 这样的静态类型的编程语言,Null 引用的存在让类型系统的许多优势付诸东流。
function capitalize(string) {
if (string == null) throw "string is required";
return string.charAt(0).toUpperCase() + string.slice(1);
}
这样的运行时检查实际上只是为不良的语言设计做出的妥协。它们用样板打乱了我们的代码。最糟糕的是,我们无法确保不会忘记检查 null。
设计良好的编程语言应该在编译时检查值是否缺失。通常我们可以使用 Option 模式实现。下面是一个 ReasonML 的示例:
let happyBirthday = (user: option(string)) => {
switch (user) {
| Some(person) => "Happy birthday " ++ person.name
| None => "Please login first"
};
};
那么,TypeScript 如何呢?TypeScript 2.0 增加了对非空类型的支持,可以通过编译器标志--strictNullChecks 来激活。但是,非空类型的编程并不是默认设置,而且不是 TypeScript 中的惯用做法。
TypeScript 太糟了。
不可变性?
我认为,在构建可变对象的大型对象图时,大型面向对象的程序将面临复杂性越来越高的问题。你需要明白并时刻牢记,在调用方法时会发生什么以及副作用是什么。
—— Rich Hickey, Clojure 的创建者
如今,不可变值的编程越来越流行。即使是 React 等现代 UI 库,其设计的意图也是与不可变值一起使用。不可变性消除了代码库中的一大类 bug。
什么是不可变状态?简单来说,就是不变的数据。就像大多数编程语言中的字符串一样。举个例子,将一个字符串转换成大写的操作并不会修改原始字符串,它始终会返回一个新字符串。
不可变性则更进一步,可以确保一切都不会改变。数组操作始终会返回一个新数组,而不是修改原始数组。更新用户名?返回一个新的用户对象,该对象拥有新的名称,而原来的对象则保持不变。
在不可变状态下,一切都不会被共享,因此我们不必再担心线程的安全性。不可变性让我们的代码易于并行化。
不会改变任何状态的函数称为纯函数,这类函数很容易测试和推理。在使用纯函数时,我们永远不必担心函数之外的任何事情。你只需要关注正在使用的函数,其他一切都可以忽略。可以想象,如此一来开发会变得多么容易(相较而言,面向对象编程必须时刻考虑整个对象图)。
TypeScript 中的不可变性?
TypeScript 对于不可变数据结构的处理甚至比 JavaScript 还要糟糕。JavaScript 开发人员可以使用支持不可变性的库,但 TypeScript 开发人员则必须依赖原生的数组/对象展开运算符(写时复制):
const oldArray = [1, 2];
const newArray = [...oldArray, 3];
const oldPerson = {
name: {
first: "John",
last: "Snow"
},
age: 30
};
// Performing deep object copy is rather cumbersome
const newPerson = {
...oldPerson,
name: {
...oldPerson.name,
first: "Jon"
}
};
不幸的是,原生的展开运算符不支持深层复制,而手动展开深层对象会很麻烦。复制大型数组/对象也会影响到性能。
TypeScript 中的关键字 readonly 很好,可以让属性不可变。但是,要想正确地支持不可变数据结构,TypeScript 还需要多加努力。
JavaScript 拥有很多处理不可变数据的库,比如 Rambda/Immutable.js。但是,这些库无法很好地与 TypeScript 类型系统一起使用。
就不可变性而言,明显是 JavaScript 更胜一筹,TypeScript 太糟了。
TypeScript&React:噩梦
利用 JavaScript 和 TypeScript 处理不可变数据,远比使用专门为此设计的语言(例如 Clojure)困难。
—— React 文档
继续讨论上述缺点,前端 Web 开发很可能会采用 React。React 并不是为 TypeScript 设计的。React 最初是函数式编程语言设计(稍后会详细介绍)。二者的编程范例之间存在冲突,TypeScript 是命令式的编程语言,而 React 函数式编程语言。
React 希望 props 是不可变的,而 TypeScript 没有不可变数据结构的内置支持。
性能如何?一不小心,就会引入棘手的性能问题:
<HugeList options=[] />
这种看上去很无辜的代码可能会成为性能方面的噩梦,因为在 JavaScript 中[] != []。上面的代码会导致 HugeList 在每次更新时都重新渲染,即使 options 值未发生变化也是如此。多个这种问题组合在一起就会导致 UI 无法使用。
TypeScript 提供的唯一一个好处就是 React 开发不必担心 PropTypes,这一点比 JavaScript 强。但考虑到其他缺点,这个好处实在是杯水车薪。
JavaScript 的超集
没错,TypeScript 是 JavaScript 的超集,这一点对于 TypeScript 的采用起到了很大的帮助作用。毕竟,很多人都十分熟悉 JavaScript。
但是,TypeScript 作为 JavaScript 的超集有更多不利的因素。这意味着TypeScript 包含所有的 JavaScript 包。JavaScript 所有的设计错误都会限制 TypeScript 的发展。
举个例子,有多少人喜欢关键字 this?恐怕没几个人,但是 TypeScript 有意将其保留了下来。
有时类型系统也会出现奇怪的现象?
[] == ![]; // -> true
NaN === NaN; // -> false
换句话说,TypeScript 继承了 JavaScript 所有的缺点,JavaScript 本身就不怎么样,作为超集的 TypeScript 又能好到哪里?
TypeScript 太糟了。
代数数据类型?
一个优秀的类型系统应该支持代数数据类型(ADT)。ADT 是一种强大的建模应用程序状态的强大的方法。
没错,你可以尝试在 TypeScript 中使用代数数据类型:
interface Square {
kind: "square";
size: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Circle {
kind: "circle";
radius: number;
}
type Shape = Square | Rectangle | Circle;
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.height * s.width;
case "circle": return Math.PI * s.radius ** 2;
}
}
下面,我们来看一看用 ReasonML 来实现相同的代码:
type shape =
| Square(int)
| Rectangle(int, int)
| Circle(int);
let area = fun
| Square(size) => size * size
| Rectangle(width, height) => width * height
| Circle(radius) => 2 * pi * radius;
TypeScript 的语法不如函数式编程语言。TypeScript 2.0 中添加了可辨识联合(Discriminated Unions)。在这个 switch 中,我们匹配的字符串很容易出错,即便我们漏掉某种情况,编译器也不会发出警告。
模式匹配?
现代语言应该支持模式匹配。一般而言,模式匹配可以帮助我们编写表达能力强大的代码。
下面是一段使用函数式语言为 option (bool)编写模式匹配的示例:
type optionBool =
| Some(bool)
| None;
let optionBoolToBool = (opt: optionBool) => {
switch (opt) {
| None => false
| Some(true) => true
| Some(false) => false
}
};
相同的代码,如果不使用模式匹配:
let optionBoolToBool = opt => {
if (opt == None) {
false
} else if (opt === Some(true)) {
true
} else {
false
}
}
毫无疑问,模式匹配的写法表达能力更强,更加整洁。但是,使用 TypeScript 无法写出这样的代码,因为它的 switch 语句不支持模式匹配。
正确的模式匹配还可以提供编译时的全面保证,这意味着我们不会忘记检查所有可能的情况。TypeScript 中没有提供此类保证。
错误处理
捕获异常不是处理错误的好方法。抛出异常没问题,但只应该用在例外情况下,程序无法恢复,而且必然会崩溃。就像 null 一样,异常会破坏类型系统。
当将异常作为主要的错误处理方式时,你就无法得知函数会返回期望值,还是会崩溃。抛出异常的函数也不能用于组合。
function fetchAllComments(userId) {
const user = fetchUser(userId); // may throw
const posts = fetchPosts(user); // may throw
return posts // posts may be null, which again may cause an exception
.map(post => post.comments)
.flat();
}
很显然,仅仅因为我们无法获取某些数据而导致整个应用程序崩溃是不可能的。但这种情况经常出现,甚至超出了我们的预想。
一种选择是手动检查引发的异常,但是这种方法很脆弱(我们可能会忘记检查异常),而且还增加了很多噪声:
function fetchAllComments(userId) {
try {
const user = fetchUser(userId);
const posts = fetchPosts(user);
return posts
.map(post => post.comments)
.flat();
} catch {
return [];
}
}
如今,我们有更好的错误处理机制,可以在编译时执行类型检查。下面是一个 Rust 的示例:
// The Result is either the Ok value of type T, or an Err error of type E
enum Result<T,E> {
Ok(T),
Err(E),
}
// A function that might fail
fn random() -> Result<i32, String> {
let mut generator = rand::thread_rng();
let number = generator.gen_range(0, 1000);
if number <= 500 {
Ok(number)
} else {
Err(String::from(number.to_string() + " should be less than 500"))
}
}
// Handling the result of the function
match random() {
Ok(i) => i.to_string(),
Err(e) => e,
}
结论
总的来说,TypeScript 的优点被高估了。TypeScript 虽然保留了 JavaScript 的部分优点,为开发者提供了良好的编程语言功能,但是毕竟它也继承了 JavaScript 数十年来的一些不良设计决策。
TypeScript 的问题不在于它有哪些功能,而在于它缺少了许多功能。TypeScript 过于注重类型,缺少其他现代语言中的许多关键功能。在大多数情况下,我们最好还是使用 JavaScript,尤其是使用 React 的前端开发。
TypeScript 只是一时的炒作吗?我觉得这个问题仁者见仁,智者见智。那么为什么 TypeScript 如此受欢迎呢?原因和 Java 与 C# 受欢迎的原因相同:背后有庞大的资本支持。
既然 TypeScript 不好,那么我们应该怎么办?
让我来向你介绍一种很棒的现代语言,它不是 JavaScript 的超集。就像 TypeScript 一样,该语言是静态类型的。它由 Facebook 开发,建立在已有20年历史的成熟编程语言的基础之上。
该语言可编译为 JavaScript,因此可以访问整个 JavaScript 生态系统。它的类型系统非常出色,为代数数据类型提供了强大的支持。它的编译器几乎能够推断所有内容。
该语言本身的功能比 JavaScript 少,而且还比 JavaScript 简单。
该语言内置对不可变数据结构的支持,而且没有 Null 引用。
此外,该语言可以与 React 完美契合。实际上,React 的创建者本人就在使用这种语言。它是静态类型(就像 TypeScript 一样),无需担心 PropTypes。
你还记得上面提到的那段性能一塌糊涂,却看似很无辜的代码示例吗?
<HugeList options=[] />
这种出色的语言提供了不可变数据结构的支持,而且没有性能问题:
<Person person={
id: "0",
firstName: "John",
}
friends=[samantha, liz, bobby]
onClick={id => Js.log("clicked " ++ id)}
/>
与 TypeScript 不同,无需重新渲染任何内容,即可获得出色的 React 性能!
这是何种奇妙的语言?可能你已经猜到了,它就是 ReasonML。前端 Web 开发不二之选。我敢打赌,ReasonML 是前端 Web 开发的未来。
总结
也许一直以来 TypeScript 的目标就是 ReasonML,但它失败了。ReasonML 在 JavaScript 中添加了静态类型,同时还删除了所有不良功能(并添加了真正重要的现代功能)。
TypeScript = JavaScript + 类型
ReasonML = JavaScript + 类型 - 缺点 + 优点
原文链接:https://medium.com/swlh/typescript-will-make-you-suffer-7cc6ca4b1233
声明:本文由CSDN翻译,转载请注明来源。
CSDN 问答上线《冲榜分奖金》活动!每周周采纳榜前五名的答主可获得现金和会员卡,剩余用户会随机抽取送出幸运礼物!